Skip to content

Bind extension methods to the extended type so they are callable from the instance - #150

Open
jhonabreul wants to merge 4 commits into
QuantConnect:masterfrom
jhonabreul:feature-bind-extension-methods
Open

jhonabreul wants to merge 4 commits into
QuantConnect:masterfrom
jhonabreul:feature-bind-extension-methods

Conversation

@jhonabreul

@jhonabreul jhonabreul commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What does this implement/fix? Explain your changes.

C# extension methods were only reachable from Python through their static class, so the instance-style call every C# example uses failed:

AttributeError: 'OrderStatus' object has no attribute 'is_open'

Cause: ClassManager builds a type's Python class from its real members only, and extension methods are static methods on another type.

The change:

  • ExtensionMethods indexes the public extension methods of each scanned assembly by the type they extend, inside AssemblyManager.ScanAssembly. Framework assemblies and Python.Runtime are skipped so LINQ is not grafted onto every collection.
  • ClassManager.GetClassInfo binds the extensions of the type and of the interfaces it introduces as members, so snake-case aliases come for free. A real member with the same name wins, as in C#.
  • MethodBinder.Bind treats an extension called through an instance like a bound operator: it matches the Python arguments against the remaining parameters and prepends the instance.
  • Base-class extensions reach subclasses through the Python type hierarchy. Open generic targets (this T, this IEnumerable<T>) and by-ref targets are skipped because the binder cannot infer them from the instance.
  • The index is cleared on AssemblyManager.Initialize so a re-initialized engine rebuilds it.

Limits: targets converted to Python natives (string, decimal, DateTime, double) cannot be bound, and their extensions stay available through the static class.

Does this close any currently open issues?

No.

Any other comments?

Tests, in TestExtensionMethods:

  • Enum extension callable from the value in both snake and Pascal case, with positional and keyword arguments.
  • Class extension callable from the instance, including a default parameter.
  • Subclass inherits a base-class extension; interface extension is bound on the implementing class.
  • Real member with the same name wins over the extension.
  • Closed generic target (List<int>) is bound; open generic target is not.
  • Static call forms through the extension class and through the extended class keep working.
  • Python-native target (string) is not bound but its static form works.
  • Full embedding suite: 1003 passed, 0 failed.

… the instance

C# extension methods were only reachable from Python through their static
class, so instance-style calls such as order_status.is_open() failed with
AttributeError. Extension methods declared in non-framework assemblies are
now indexed during the assembly scan and bound as members of the type they
extend, including interface targets on implementing classes. The binder
passes the instance as the first argument when an extension method is called
through it. Real members with the same name win, and open generic targets are
skipped since the binder cannot infer them from the instance.
Targets made only of framework types are shared by countless classes:
extensions on object reached every CLR object, and Accord.Math alone added
~600 methods to each primitive array type. Across Lean's assemblies the
index shrinks from 16487 to 653 methods. Such extensions keep their static
form. Assemblies without the compiler's assembly-level [Extension] marker
are skipped up front, as C# does, and binding a class only walks its
interfaces when an interface target was indexed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Martin-Molinero

Martin-Molinero commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Review follow-up: pushed 86be7c6 and e2af525

Where: src/runtime/ExtensionMethods.cs, src/embed_tests/TestExtensionMethods.cs, CHANGELOG.md

  • 86be7c6, Bind extension methods only to targets involving a non-framework type
  • e2af525, Keep extension methods marked EditorBrowsable(Never) in their static form (see the last section)

What

Extension methods are now bound to an instance only when their target involves a non-framework type: the type itself, an array's element type, or a generic argument.

Target Before After
OrderStatus, Order, Symbol, user interfaces bound bound
IEnumerable<Symbol>, List<UserType> bound bound
object bound to every CLR object static form only
IEnumerable, IDisposable, IConvertible bound to every implementer static form only
double[], decimal[][], List<int>, Type bound static form only

Extensions that are not bound still work through their static class, e.g. Extensions.convert_invariant(x).

Why

Only the declaring assembly was checked against the framework list, not the extended type. Measured over the 88 assemblies in Lean's Launcher/bin/Debug:

  • 16,487 extension methods were indexed, about 95% of them on framework-only targets.
  • Accord.Math alone adds ~600 extension methods to every primitive array type. Arrays stay CLR objects in Python, so each array class would get ~1,200 extra attributes (method plus snake_case alias).
  • Lean's own ConvertInvariant(this object) showed up on every CLR object, ToPyList(this IEnumerable) on every collection and DisposeSafely(this IDisposable) on every disposable. All of them also fed into the attribute suggestions.

How

  • IsBindableTarget decides which targets are indexed. It skips by-ref and open generic targets (same as before) and recurses into array element types and generic arguments.
  • Assemblies without the compiler's assembly-level [Extension] marker are skipped up front. C# relies on the same marker, and it skips 39 of the 88 assemblies.
  • GetMethodsToBind only walks a class's interfaces when some interface target was indexed, and doesn't allocate for classes that get nothing.
  • Framework checks are cached per assembly.

Result: the index goes from 16,487 to 653 methods. The one-time Register cost goes from about 30 ms to 24 ms, most of which is now reading Accord's methods just to discard them. I considered making the scan lazy, but it wouldn't help: Lean binds classes right away, and finding which static class extends a type requires the scan anyway.

Behaviour change: the existing this List<int> test now asserts that the extension is not bound. The positive closed-generic case moved to List<ExtendedModel> and IEnumerable<ExtendedModel>.

Hiding helpers with [EditorBrowsable(Never)] (e2af525)

What: extension methods marked [EditorBrowsable(EditorBrowsableState.Never)], and all extension methods of a static class marked so, are no longer bound to the instance. Their static form keeps working. Advanced is not affected.

Why: binding exposes engine plumbing on user objects. For example, every algorithm gets self.set_runtime_error, self.process_security_changes, self.create_option_chain, self.create_future_chain and self.get_or_add_unrequested_security from Lean's IAlgorithm extensions. EditorBrowsable(Never) is the standard .NET way to say "callable, but don't advertise it" (IntelliSense hides such members), so pythonnet honors it without knowing any Lean-specific attribute.

How: IsHidden checks the attribute on the extension class and on each method that passes the target filter.

  • Cost: it only runs while building the index at startup, about 0.1 ms for Lean. Even run over all 18,698 extension methods in Lean's bin folder it takes about 3 ms. There's no cost per class bound or per call.
  • Behaviour today: no Lean method carries the attribute, so nothing changes until Lean marks the helpers it wants hidden. Across Lean's dependencies only McMaster's OnExecute is affected.

Note: the stubs generator (QuantConnect/quantconnect-stubs-generator#104) should skip the instance form for these methods too. Otherwise the stubs would accept instance calls that fail at runtime.

Tests

  • TestExtensionMethods: 12/12. New tests: ClosedGenericTargetOverAUserTypeIsBound, FrameworkOnlyTargetsKeepTheStaticFormOnly and EditorBrowsableNeverKeepsTheStaticFormOnly. The last one fails without e2af525. The second covers object, IEnumerable, IDisposable, double[] and List<int>, checks StringBuilder doesn't pick up an object extension, and checks the static forms still work.
  • Full embed suite: 1020 passed, 0 failed (net10.0, Python 3.11).
  • Lean Python regression algorithms: 339/339 passed, using the stacked 2.0.68 build from Give types with an implicit numeric conversion Python's number protocol #151 dropped into Lean master 154e50720c, following .github/workflows/lean-python-regression-tests.yml. This run was before e2af525, which changes nothing for Lean today.

🤖 Generated with Claude Code

…form

[EditorBrowsable(EditorBrowsableState.Never)] hides a member from
IntelliSense while keeping it callable. Binding such extension methods to
the instance would advertise them anyway, so those methods, and all the
extension methods of a class marked so, keep their static form only. It
lets a library hide plumbing helpers, like Lean's IAlgorithm extensions,
from the instance with a standard attribute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants